Skip to content

feat(worker): stop polling idle workers on Redis and PostgreSQL - #34

Open
RomainLanz wants to merge 15 commits into
mainfrom
feat/worker-wake-signals
Open

RomainLanz wants to merge 15 commits into
mainfrom
feat/worker-wake-signals

Conversation

@RomainLanz

@RomainLanz RomainLanz commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Problem

An idle worker polls its queues every idleDelay (2 s by default). Each poll claims the due schedules and pops every queue, so many workers send many requests for no work, and a new job waits up to idleDelay before it runs.

Solution

Adapters can implement an optional watch(queues, signal). It returns a watcher the worker waits on when it is idle, instead of polling. The worker still checks its queues every stalledInterval, in case a wake was lost.

Redis, on by default. Workers wait on markers with BZPOPMIN, as BullMQ does:

  • the scripts that push or retry a job add a marker to its queue, and BZPOPMIN hands it to one waiting worker;
  • a worker that pops a job adds the marker again while jobs remain, to wake the next one;
  • delayed jobs and schedules keep their next due time in a marker that one worker holds, and puts back when it gets busy first;
  • markers outlive reconnections, and a blocking connection that goes silent or closes is replaced.

PostgreSQL (Knex), opt-in with knex(config, { notifications: true }). Workers LISTEN, and a push sends pg_notify on its own connection, so a job dispatched in a transaction wakes workers when it commits. It stays off by default because its cost grows with the number of workers: one more connection per worker, and every NOTIFY reaches every worker. Its cost is bounded:

  • a worker woken for a job another worker took waits idleDelay before it wakes again;
  • an adapter sends at most one NOTIFY per 50 ms outside transactions;
  • the LISTEN connection pings every 10 s, and reconnects when it gets no answer.

MySQL, SQLite, Kysely: no change.

The worker now acquires jobs one at a time until its pool is full or the queues are empty. Before, a worker with a concurrency of 10 that found one job sent 9 more acquisitions at once, which a wake per job made costly.

Upgrading

  • Redis: nothing to change. Upgrade the processes that dispatch jobs with or before the workers: a job pushed by an older version adds no marker, and waits for the next stalled check (30 s by default). Each worker holds one more Redis connection.
  • Redis users without the @blocking ACL category: workers fail to watch, log it once, and poll as before.
  • PostgreSQL: nothing changes unless you set notifications: true. Set it on every process that dispatches jobs, not only on workers. Leave it off behind PgBouncer in transaction mode. Size max_connections for 2 connections per worker.

Proof

Three orbs ran this PR against main (ea13357), on real Redis 7 and PostgreSQL 16, first at 1d758aa, then again at 8c66561 with the same harness, seed and settings. The load tests capped each database at 2 CPUs. At 8c66561, the soak and the compatibility checks matched 1d758aa, and 684 interleaved load runs found no difference beyond noise between the two. The numbers below are from 8c66561 unless noted.

Soak with faults, 45 min plus a 240 s drain per backend, 4 workers, about 11k mixed jobs (immediate, delayed, retries, dedup, interval and cron schedules). Each run got the same 53 faults: blocking and LISTEN connection kills, database restarts, worker SIGKILL and SIGSTOP, silenced connections and reconnections, a worker partition, SIGTERM during faults.

Redis main → PR PostgreSQL main → PR
Jobs lost 0 → 0 0 → 0
Completed twice (DB restarts or SIGSTOP, at-least-once) 0 → 1 3 → 6
Quiet immediate pickup p50 / p99 297 / 1677 → 6 / 11 ms 289 / 1721 → 15 / 1460 ms
Worst lateness 14.0 → 2.0 s 15.9 → 13.6 s

No crash, unhandled error, stuck stop(), leftover connection, dedup or schedule violation. RSS stayed flat.

Load and scale (at 1d758aa, before the host change below)

main this PR
Redis, 100 idle workers, commands/s 256 33
PostgreSQL with notifications, 100 idle workers, statements/s / CPU 457 / 7.9% 50 / 1.5%
Redis, 100 workers at 1000 jobs/s, pickup p50 / p99 160 / 1146 ms 1 / 3 ms
Redis, 32 workers at 5000 jobs/s, drained jobs/s 4481 4999
PostgreSQL with notifications, 32 workers at 100 jobs/s, pickup p99 1922 ms 50 ms

Without notifications, PostgreSQL matches main at concurrency 1, and opens 3 to 5× fewer connections at concurrency 10 because workers acquire jobs one at a time.

Compatibility: the full suite on Redis, PostgreSQL, MySQL 8.4 and SQLite; mixed versions and a rolling upgrade under load (360/360 jobs); PostgreSQL without the option sends the same statements as main; PgBouncer in transaction mode falls back to polling; keyPrefix, ACL users, a shared ioredis connection as in @adonisjs/queue 0.7; a custom adapter written for main; additive .d.ts only.

Limits:

  • Redis: 2 connections per worker, 1.2 to 1.8× the commands per job under load. When Redis saturates, that costs throughput: at 100 workers and 10,000 jobs/s, 8% fewer jobs on the first host, 46% fewer on a host about 30% slower; at 100 workers with a concurrency of 10 and 5000 jobs/s, 29% fewer on the slower host, where main keeps up. Below saturation, pickup p99 is 2 to 90 ms against 0.8 to 2 s on main.
  • PostgreSQL with notifications: 2 connections per idle worker, up to 3 under load. With max_connections=100, 90 workers leave dispatchers without connections. On a saturated database (5000 jobs/s on 2 CPUs), pushes are 7 to 22% slower. Hence opt-in.
  • A connection that goes silent without FIN or RST is detected within 15 s; until then a lone worker finds work at its stalled check.
  • Not tested: Redis Cluster or Sentinel, managed PostgreSQL, a real network, runs longer than 45 minutes.

Tests

  • tests/worker_session.spec.ts:
    • a watching session sleeps until a wake, and wakes at a due time;
    • it keeps a wake received while looking for work, and cools down after a wake that found no work;
    • it polls while the watcher is not ready or fails;
    • it acquires one job at a time.
  • tests/_utils/register_work_watcher_suite.ts, for Redis and Knex (PostgreSQL): wakes for pushes, delayed jobs, retries and schedules; work stored before watching; the next delayed job of each queue; expired and deleted schedules; transaction commit and rollback.
  • Redis:
    • one marker wakes one worker, and the next one while jobs remain;
    • a held due time goes to another worker;
    • a silent and a closed blocking connection recover;
    • ACL users without channel permissions or without @blocking;
    • a blocking connection that never starts.
  • PostgreSQL:
    • the cooldown and one NOTIFY per interval across queues;
    • no pool connection for watching;
    • a silent LISTEN connection, a LISTEN behind a pooler that receives nothing, handshakes aborted on close;
    • expiring connection settings, a table name too long for a channel, the knex() options;
    • a stalled replacement LISTEN connection, probe connection errors and stalls, a stalled or late settings lookup;
    • non-object payloads, the Knex searchPath, and no signal for a deduplicated dispatch.

The full suite passes (1742 tests) on Redis, PostgreSQL, MySQL 8.4 and SQLite.

An idle worker polled its queues every idleDelay (2s by default): on
PostgreSQL, several transactions per queue on every poll, and up to
idleDelay of latency before a new job ran.

Adapters may now implement subscribe(queues, onWake, signal). onWake
receives the time at which work becomes due: now for a new job, later
for a delayed job, a retry, or a schedule run. A subscribed worker
sleeps until a signal for now, the next due time it was told about, or
the next stalled check, which is also the poll that finds lost signals.
The worker polls until its subscription is ready, and aborts it when
it stops first.

Adapters announce now, the next delayed job of each queue, and the
next schedule that can run when they subscribe and after each
reconnection. A pop that promotes delayed jobs announces the next
delayed job of its queue.

- Redis publishes from the Lua scripts that write the job or schedule,
  so the signal goes with the write in one round trip, and listens on
  a duplicated connection.
- Knex on PostgreSQL sends pg_notify on the connection of the write: a
  job dispatched in an application transaction is signaled on commit,
  and not at all on rollback. A connection outside the pool runs
  LISTEN; the adapter owns its client before it connects, so it can
  end it in its handshake, and calls a connection provider again once
  its settings expire.

MySQL, SQLite, and Kysely keep polling. The knex() factory now accepts
options as its second argument, including notifications: false for
poolers without LISTEN support such as PgBouncer in transaction mode.
A table name string still works.
Tests against PgBouncer, Redis ACLs and multiple hosts found cases
where wake signals broke writes, slowed workers or went unnoticed:

- Redis: a user without channel permissions failed every push after
  its writes, because PUBLISH raised inside the scripts. Scripts now
  use redis.pcall, so the write succeeds and the worker polls. The
  channel also includes the database, since pub/sub ignores it.
- PostgreSQL behind PgBouncer in transaction mode: LISTEN succeeded
  but never received anything, so jobs waited for the stalled check.
  The subscriber now sends a probe notification and falls back to
  polling with a clear message when it does not arrive.
- A table name over 63 bytes failed pg_notify. The channel name is
  hashed in that case.
- "Now" signals carried the producer clock: a producer ahead of the
  worker delayed jobs until the stalled check. They no longer carry a
  timestamp, so the worker uses its own clock.
- Sequential pushes lost about a third of their throughput, as
  PostgreSQL serializes NOTIFY commits. Signals for ready jobs are now
  coalesced per queue, and a delayed job is inserted and signalled in
  one statement.
A subscription connection can stop delivering without closing, behind
a stuck proxy or a half-open TCP connection. The worker then ran jobs
only at its stalled check, 30 s by default, until it restarted.

The subscriber now sends PING on Redis, or SELECT 1 on the LISTEN
connection, every 10 s, and reconnects when it gets no answer within
5 s. The reconnection announces due work, as after any reconnection.
A load test showed the wake signals did not scale. Every push woke
every idle worker, and each ran a full cycle: on PostgreSQL with 100
workers at 1000 jobs/s, throughput fell from 635 to 234 jobs/s. Every
NOTIFY also reached every LISTEN connection, an idle PostgreSQL worker
held 3 connections instead of 1, and every schedule claim made every
worker read the next schedule.

Adapters now implement watch(), which returns a watcher the worker
waits on, instead of subscribe(), which pushed signals to it.

Redis waits on markers with BZPOPMIN, as BullMQ does. A push adds a
marker to its queue; BZPOPMIN hands it to one waiting worker, and a
worker that pops a job adds it again while jobs remain. Delayed jobs
and schedules keep their next due time in a marker that one worker
holds, and puts back when it stops waiting first. Markers outlive a
reconnection, so the pub/sub channel and its announcements are gone.

PostgreSQL keeps LISTEN/NOTIFY, which wakes every worker, and bounds
what it costs:

- A worker woken for nothing waits idleDelay before it wakes again, so
  idle workers query no more often than when polling.
- Outside of a transaction, a queue gets at most one NOTIFY per 50 ms
  per adapter.
- Schedule changes carry the next run, read once by the writer.
- The LISTEN connection reads what it announces, and the probe uses a
  short-lived connection, so the pool does not grow.
ioredis reconnects a closed connection but never settles the command
that was in flight on it. After a server-side kill, the watcher waited
for its watchdog, up to the BZPOPMIN timeout plus 5 s, and work due
meanwhile started that late.

The watcher now ends the read when the connection closes, and looks
for work at once, since Redis may have popped a marker whose reply was
lost.
After a probe found a job, the worker asked for one job per free slot
at once. A worker woken for a single job with a concurrency of 10 sent
10 acquisitions, 9 of them empty: on Redis, about 6 times the commands
per job of polling.

The worker now acquires one job at a time until its pool is full or a
queue runs dry, as BullMQ does. An empty queue costs one acquisition.
On PostgreSQL, LISTEN/NOTIFY costs grow with the number of workers:
each worker holds one more connection, and every NOTIFY reaches every
LISTEN connection. A load test with 100 workers doubled the database
CPU, and with max_connections at 100, 40 workers fit instead of 90.
Enabled by default, an upgrade alone could exhaust the connections of
a deployment.

Workers on PostgreSQL now poll unless the adapter is created with
`notifications: true`, on every process that dispatches jobs.

An adapter now sends at most one NOTIFY per 50 ms for all its queues,
instead of one per queue: with 50 queues, the limit did not hold.
Four failure paths escaped the fallback to polling:

- The PostgreSQL probe armed its 2s timeout before its connection
  completed. A probe connection that stalled left the rejection
  unobserved, and Node exited instead of falling back to polling.
- Aborting a Redis watch only disconnected the blocking client. A PING
  queued during a silent handshake never settled, so stopping the
  worker hung. Abort now rejects the PING.
- A notification whose payload parsed to null threw in the pg event
  handler. Any role that can connect can send one, and it killed the
  worker. Payloads that are not objects are now ignored.
- The LISTEN connection did not apply the Knex searchPath, so its
  announcement queries failed for unqualified tables found only
  through it. It now sets the search path as the pool does.
A replacement LISTEN connection that stalled in its handshake or setup
never failed: the heartbeat skips a connection until it listens, so the
watcher stayed without notifications until the worker restarted, and
closing it waited on a server that never answered. The whole startup
of a LISTEN connection now has a deadline, after which the connection
is dropped and the reconnection goes on. A connection that does not end
gracefully is destroyed.

A deduplicated dispatch signalled its attempted due time even when it
added no job. That time could replace the real due time of another job
in the same notification, and the wake found nothing to promote, so
nothing announced the real one. Only added jobs signal now.
The probe client had no error listener: a connection failure while it
waited for its notification emitted an unhandled error event and
killed the process, instead of rejecting watch(). Its errors now reject
the probe.

The probe client also ended gracefully without a bound, so a connection
stalled in its handshake kept its socket open and could hold the
process at shutdown. It now ends like the LISTEN client: its socket is
destroyed when end() does not settle in time. Both keep an error
listener while they end.
A LISTEN connection looked its settings up, through the async provider
of Knex, before its startup deadline and abort handling applied. A
lookup that stalled made aborting the watch wait for it, so stopping a
worker hung, and a reconnection could stall forever. The lookup now
runs within the bounded startup, and a client it returns once the
attempt was given up stays unused.
A Redis user without the @Blocking ACL category could watch, then every
BZPOPMIN failed with NOPERM: the worker logged an error and retried
every second, forever. watch() now probes BZPOPMIN in a MULTI, where it
returns at once, so such a worker fails to watch and polls instead.
With notifications, each Worker holds 2 connections: a max_connections
sized for 1 per Worker leaves dispatchers without any. A connection
that goes silent is detected by the heartbeat within 15 s, and the
stalled check finds work until then.
A lookup abandoned at the startup deadline could answer after a later
one had restored LISTEN, and overwrite its settings. The next
reconnection then used stale credentials, and failed. Only the latest
lookup updates the cached settings now.
ioredis drops a command sent before a reconnection, without settling
it: the startup of watch() then waited forever, and the worker polled
without logging why. Its startup now has a deadline, after which
watch() fails and the worker polls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant